58 — 用 R3F 搭建环境与布景

Three.js Journey · Bruno Simon · 难度:简单

58 — 用 R3F 搭建环境与布景 课程海报

简介

到目前为止,我们一直使用一个非常简单的环境,只有一盏方向光和一盏环境光。

在本课中,我们将了解一些可用的环境功能,以及如何在 R3F 中实现它们。

准备工作

起始项目和上一课非常相似,包含一个球体、一个立方体和一块绿色地板。

此外,得益于 useFrame,立方体正在旋转;我们还在 r3f-perf 中添加了 <Perf /> 以便时刻关注性能。

@react-three/drei 依赖已经安装在项目中,我们使用 OrbitControls 辅助工具来移动相机。

背景颜色

为了学习,我们将探索多种更改背景颜色的方法。

请注意,如果你只想要一种纯色,那么这些技术中的任何一种都是可行的解决方案,取决于你的个人偏好。在一些非常特殊的情况下,比如使用后处理时,你选择的技术可能会产生不同的结果,不过我们会在未来的课程中看到这一点。

使用 CSS

默认背景颜色看起来是白色的,但正如我们在上一课中看到的,它实际上是透明的,我们看到的是页面的 HTML 背景。

这意味着我们可以直接在 CSS 中更改颜色。

在 /src/style.css 中,将 background-color(或者直接 background)设置为 red:

html,
body,
#root
{
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: red;
}

就是这样。很简单,对吧?不过让我们再看看其他技术。

注释掉或删除 background 属性:

html,
body,
#root
{
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    // background: red;
}

使用 renderer 上的 setClearColor

WebGLRenderer 有一个名为 setClearColor 的方法。它可以在渲染场景中的各种对象之前,用一种颜色填充 <canvas>。

要使用 setClearColor,我们需要访问 renderer,并且只需在 renderer 创建时执行一次。

在 index.jsx 中创建一个 created 函数,并将它传给 <Canvas> 的 onCreated 属性:

const created = () =>
{
    console.log('created')
}

root.render(
    <Canvas
        camera={ {
            fov: 45,
            near: 0.1,
            far: 200,
            position: [ - 4, 3, 6 ]
        } }
        onCreated={ created }
    >
        <Experience />
    </Canvas>
)

state 会作为函数的一个参数传入,而 renderer 将在 gl 属性中可用:

const created = (state) =>
{
    console.log(state.gl)
}

如果你喜欢解构:

const created = ({ gl }) =>
{
    console.log(gl)
}

然后我们可以调用 setClearColor,第一个参数是颜色,第二个参数是 alpha:

const created = ({ gl }) =>
{
    gl.setClearColor('#ff0000', 1)
}

使用 scene 的背景

那是一个可行的解决方案,不过让我们看看另一个方案。

我们不用在 renderer 上做,而是可以在 scene 上做,并且可以用同样的方式在 created 函数中访问它:

const created = ({ scene }) =>
{
    console.log(scene)
}

现在我们可以用 Three.js 实例化一个 Color,并将它赋给 background 属性(别忘了从 three 中导入 THREE 或仅导入 Color)

import * as THREE from 'three'

// ...

const created = ({ scene }) =>
{
    scene.background = new THREE.Color('#ff0000')
}

使用 R3F color

那也是一个不错的解决方案,但等等,还有一个更好的选择。

我们不必监听事件并导入 Three.js,而是可以直接在 JSX 中创建颜色。

首先,让我们做一些清理,注释掉或删除 THREE 的导入、created 函数以及 onCreated 属性:

import './style.css'
import ReactDOM from 'react-dom/client'
import { Canvas } from '@react-three/fiber'
import Experience from './Experience.jsx'

const root = ReactDOM.createRoot(document.querySelector('#root'))

root.render(
    <Canvas
        camera={ {
            fov: 45,
            near: 0.1,
            far: 200,
            position: [ - 4, 3, 6 ]
        } }
    >
        <Experience />
    </Canvas>
)

然后,在 <Canvas> 内创建一个 <color>:

<Canvas
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ 1, 2, 6 ]
    } }
>
    <color args={ [ '#ff0000' ] } />
    <Experience />
</Canvas>

这样做会自动创建一个 Color 实例,但该实例并没有被赋给 scene.background。

技巧就在这里。

我们可以添加一个 attach 属性,指定这个组件应该附加到什么上。

<color args={ [ '#ff0000' ] } attach="background" />

这里 scene 是隐含的,因为它是唯一的父级。

实际上我们可以把这段代码放在任何地方,只要直接父级是 scene 即可;如果我们把它放在 Experience 中,情况仍然如此:

export default function Experience()
{
    // ...

    return <>

        <color args={ [ '#ff0000' ] } attach="background" />

        {/* ... */}

    </>
}

让我们把它改成一种不那么刺眼的颜色,比如 'ivory':

<color args={ [ 'ivory' ] } attach="background" />

(接下来的截图没有使用这个背景颜色,所以它们看起来会更白)。

灯光

R3F 支持所有 Three.js 默认灯光:

我们不打算测试它们,因为我们在 灯光 那一课已经见过它们的工作方式,而且它们在 R3F 中的工作方式完全相同。

灯光辅助工具

我们仍然可以使用 Three.js 的灯光辅助工具。

为此,我们将使用 drei 中的 useHelper,但首先,我们需要一个对 <directionalLight> 的引用。

useRef 已经从 react 中导入,用于给立方体添加动画。

创建一个 directionalLight 引用:

export default function Experience()
{
    const directionalLight = useRef()

    // ...
}

使用 ref 属性将它关联到 <directionalLight>:

<directionalLight ref={ directionalLight } position={ [ 1, 2, 3 ] } intensity={ 4.5 } />

从 @react-three/drei 导入 useHelper:

import { useHelper, OrbitControls } from '@react-three/drei'

useHelper 的第一个参数是光源的引用,第二个参数是我们要使用的 Three.js 辅助类。

这意味着我们首先需要导入 THREE 才能访问 DirectionalLightHelper 类:

import * as THREE from 'three'

(我们也可以只导入 DirectionalLightHelper 类)。

最后,我们可以调用 useHelper(),传入引用(directionLight)、辅助类(THREE.DirectionalLightHelper)以及辅助工具的参数(在我们的例子中是尺寸):

export default function Experience()
{
    const directionalLight = useRef()
    useHelper(directionalLight, THREE.DirectionalLightHelper, 1)

    // ...
}

它由白线组成,所以几乎看不见,但移动相机后你应该能看到它。

useHelper 不只是用于灯光,例如我们也可以用 CameraHelper 把它用在相机上。

阴影

为了让场景更真实,我们需要阴影。

我们会先从 Three.js 默认的阴影系统开始,然后我们会看到一些借助 R3F 和 drei 变得更简单的其他阴影解决方案。

默认阴影

激活

要在 WebGLRenderer 上激活阴影渲染,我们只需在 index.jsx 中给 <Canvas> 添加一个 shadows 属性:

root.render(
    <Canvas
        shadows
        camera={ {
            fov: 45,
            near: 0.1,
            far: 200,
            position: [ - 4, 3, 6 ]
        } }
    >
        <Experience />
    </Canvas>
)

回到 Experience.jsx,我们给 <directionalLight> 添加 castShadow:

<directionalLight ref={ directionalLight } castShadow position={ [ 1, 2, 3 ] } intensity={ 4.5 } />

给球体 <mesh> 和立方体 <mesh> 添加 castShadow(这些对象只需要投射阴影,因为它们上方没有任何东西):

<mesh castShadow position-x={ - 2 }>
    {/* ... */}
</mesh>

<mesh castShadow position-x={ 2 } scale={ 1.5 }>
    {/* ... */}
</mesh>

最后,给地板 <mesh> 添加 receiveShadow(地板只需要接收阴影,因为它下方没有任何东西):

<mesh receiveShadow position-y={ - 1 } rotation-x={ - Math.PI * 0.5 } scale={ 10 }>
    {/* ... */}
</mesh>

只需要这些。

正如你在性能监控上看到的,渲染所需时间明显上升了,尽管仍然可以接受。

烘焙

如果我们的场景是静态的(这里并不是,因为立方体在旋转,但我们还是照样做一下),我们可以添加 drei 的 BakeShadows 辅助工具。

它只会渲染阴影一次,而不是每一帧都渲染。

从 @react-three/drei 导入 BakeShadows:

import { BakeShadows, useHelper, OrbitControls } from '@react-three/drei'

把它添加到 JSX 中:

export default function Experience()
{
    // ...

    return <>

        <BakeShadows />
        {/* ... */}

    </>
}
lesson demo video preview

正如你可以从立方体的阴影看到的,阴影不再每一帧更新,这提升了性能。

如果你查看性能监控,你会发现渲染所需时间比之前更少,但仍然稍多一点,因为那个烘焙的阴影仍然需要在最终结果中渲染。

配置阴影

默认设置看起来相当不错,但我们可以对它进行调整。

正如在之前的课程中讨论过的,每一盏投射阴影的灯光都会以一种特定的方式渲染场景并输出我们称之为“阴影贴图”(shadow map)的东西。然后这个阴影贴图被用来判断某个表面是否处于阴影中。

默认情况下,为了保持稳定的性能,这个阴影贴图的分辨率相当低。

幸运的是,我们可以更改它。

在纯 JavaScript 中,我们可以通过 directionalLight.shadow.mapSize.set(1024, 1024) 来访问它,但在 R3F 中我们该怎么做呢?

好消息是,大多数属性(甚至是深层的属性)仍然可以直接通过属性访问,只需用短横线 - 分隔不同的层级。

举个例子,要更改 shadow.mapSize 属性,我们可以使用 shadow-mapSize 属性(你可能需要重新加载):

<directionalLight
    ref={ directionalLight }
    position={ [ 1, 2, 3 ] }
    intensity={ 4.5 }
    castShadow
    shadow-mapSize={ [ 1024, 1024 ] }
/>

对于 near、far、top、right、bottom 和 left 属性我们也可以这样做(因为渲染阴影贴图使用的是 OrthographicCamera):

<directionalLight
    ref={ directionalLight }
    position={ [ 1, 2, 3 ] }
    intensity={ 4.5 }
    castShadow
    shadow-mapSize={ [ 1024, 1024 ] }
    shadow-camera-near={ 1 }
    shadow-camera-far={ 10 }
    shadow-camera-top={ 2 }
    shadow-camera-right={ 2 }
    shadow-camera-bottom={ - 2 }
    shadow-camera-left={ - 2 }
/>

阴影被裁剪了,因为值太小了,但这里的主要目的是解释如何调整这些阴影。

让我们把 top、right、bottom 和 left 属性改回 5:

<directionalLight
    ref={ directionalLight }
    position={ [ 1, 2, 3 ] }
    intensity={ 4.5 }
    castShadow
    shadow-mapSize={ [ 1024, 1024 ] }
    shadow-camera-near={ 1 }
    shadow-camera-far={ 10 }
    shadow-camera-top={ 5 }
    shadow-camera-right={ 5 }
    shadow-camera-bottom={ - 5 }
    shadow-camera-left={ - 5 }
/>

柔和阴影

默认阴影太锐利了。有多种方法可以让它们变柔和,我们将探索一种称为 Percent Closer Soft Shadows(PCSS)的技术。

其思路是根据投射阴影的表面与接收阴影的表面之间的距离,在偏移位置采样阴影贴图纹理,从而让阴影看起来模糊,这多少有点接近现实中的情形。

在 Three.js 中,这要归功于 spidersharma03 得以实现,我们可以在这里找到一个示例 https://threejs.org/examples/#webgl_shadowmap_pcss

如果你查看代码,你会发现实现这个方案需要直接修改 Three.js 的 shader 片段,这有点麻烦。

幸运的是,drei 提供了一个名为 <SoftShadows> 的辅助工具来救场。

在 Experience.jsx 中,从 @react-three/drei 导入 SoftShadows:

import { SoftShadows, BakeShadows, useHelper, OrbitControls } from '@react-three/drei'

然后把它添加到 <BakeShadows /> 之后,并带上以下属性:

export default function Experience()
{
    // ...

    return <>

        <BakeShadows />
        <SoftShadows size={ 25 } samples={ 10 } focus={ 0 } />

        {/* ... */}

    </>
}

现在看看阴影,它们看起来多么漂亮、柔和。

请注意,这个辅助工具会直接修改 Three.js 的 shader,并且对它属性的每次更改都会重新编译所有支持阴影的 shader。这会导致性能问题以及帧率的急剧下降。

尽管如此,你仍然可以调整这些属性,甚至把它们添加到 leva 中,但这只是为了找到最适合你的调整。之后,就不要再更改它们了。

注释掉或删除 <BakeShadows />,以便再次看到阴影移动。

{/* <BakeShadows /> */}

累积阴影

正如其名,AccumulativeShadows 会累积多次阴影渲染,我们将在每次渲染前随机移动灯光。这样一来,阴影将由来自不同角度的一堆渲染组成,使它看起来柔和且非常真实。

AccumulativeShadows 会渲染在一个平面上。这限制了它的用途,但它在你场景的地板上看起来会非常出色。

理解它的最好方式就是看它实际运行。

由于 AccumulativeShadows 本身就是一个阴影,我们应该停用对应地板的 <mesh> 上的阴影,以避免出现两个阴影。

移除地板 <mesh> 上的 receiveShadow:

<mesh position-y={ - 1 } rotation-x={ - Math.PI * 0.5 } scale={ 10 }>
    {/* ... */}
</mesh>

并注释掉 <SoftShadows>:

{/* <SoftShadows size={ 25 } samples={ 10 } focus={ 0 } /> */}

从 @react-three/drei 导入 AccumulativeShadows:

import { AccumulativeShadows, SoftShadows, BakeShadows, useHelper, OrbitControls } from '@react-three/drei'

把它添加到场景中 <directionalLight> 之后,并且不要自动闭合它,稍后我们会在里面放入一些东西:

<AccumulativeShadows></AccumulativeShadows>

如你所见,我们还有一些工作要做。

首先,让我们把它稍微移到地板上方:

<AccumulativeShadows
    position={ [ 0, - 0.99, 0 ] }
>
</AccumulativeShadows>

默认尺寸是 10 个单位,与我们的场景完美匹配,但如果你需要一个更小或更大的 AccumulativeShadows,可以使用 scale 属性:

<AccumulativeShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
>
</AccumulativeShadows>

由于 AccumulativeShadows 会渲染阴影贴图,我们需要为它提供实际的灯光。注意它不会使用场景中已有的灯光,这意味着我们必须复制那盏方向光。稍后,我们会用一种更好的方案来替换这盏灯。

在 <AccumulativeShadows> 中创建一个 <directionalLight>,并使用与已有 <directionalLight> 相同的 position 属性。同时添加 castShadow 属性:

<AccumulativeShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
>
    <directionalLight
        position={ [ 1, 2, 3 ] }
        castShadow
    />
</AccumulativeShadows>

阴影回来了,但它看起来比以前更糟。

AccumulativeShadows 目前正在进行大量阴影渲染,但始终来自同一方向光、同一位置。我们需要在每一帧随机移动它。

我们不用手动去做,而是使用 drei 中的 RandomizedLight 辅助工具,它正好能做到这一点,而且设置起来超级简单。

从 @react-three/drei 导入 RandomizedLight:

import { RandomizedLight, AccumulativeShadows, SoftShadows, BakeShadows, useHelper, OrbitControls } from '@react-three/drei'

把它添加到 <AccumulativeShadows> 中,替换掉 <directionalLight>,并使用相同的 position 属性:

<AccumulativeShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
>
    <RandomizedLight
        position={ [ 1, 2, 3 ] }
    />
</AccumulativeShadows>

好多了。

<RandomizedLight> 有多个属性来控制灯光的行为:

以及与阴影贴图相关的参数:

找到最佳调整参数很复杂,这正是像 Leva 这样的调试 UI 会非常有帮助的地方。

但让我们偷个懒,直接使用以下参数:

<RandomizedLight
    amount={ 8 }
    radius={ 1 }
    ambient={ 0.5 }
    intensity={ 3 }
    position={ [ 1, 2, 3 ] }
    bias={ 0.001 }
/>

回到 <AccumulativeShadows>,我们也可以访问一些属性:

由于我们有一块绿色地板,让我们把阴影调成一种深绿/偏蓝的 color,并稍微降低 opacity:

<AccumulativeShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
    color="#316d39"
    opacity={ 0.8 }
>
    {/* ... */}
</AccumulativeShadows>

首先,让我们尝试增加 frames:

<AccumulativeShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
    color="#316d39"
    opacity={ 0.8 }
    frames={ 1000 }
>
    {/* ... */}
</AccumulativeShadows>

1000 很多,但正如你所见,阴影看起来非常平滑。问题在于,Three.js 必须在第一帧一次性完成这 1000 次渲染,你可能注意到了相当长的卡顿。

这倒不一定是大问题,而且 1000 本来就太多了,但我们可以用 temporal 来防止卡顿:

<AccumulativeShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
    color="#316d39"
    opacity={ 0.8 }
    frames={ 1000 }
    temporal
>
    {/* ... */}
</AccumulativeShadows>
lesson demo video preview

如你所见,需要好几秒才能让阴影回来,但至少我们没有遇到任何卡顿,因为每一帧只进行一次渲染。

你可能注意到的另一件事是,当你移动相机时,阴影上会画出一个奇怪的形状。这是由于方向光辅助工具扰乱了阴影贴图。

让我们移除或注释掉这个辅助工具:

// useHelper(directionalLight, THREE.DirectionalLightHelper, 1)

把 frames 的数量减少到 100,这样看起来仍然不错,而且如果你以 60fps 运行,不到 2 秒就会显示出来:

<AccumulativeShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
    color="#316d39"
    opacity={ 0.8 }
    frames={ 100 }
>
    {/* ... */}
</AccumulativeShadows>

你可能已经注意到,阴影似乎并没有随着立方体移动,这是真的。

为了更清楚地看到这一点,让我们让立方体左右移动。

在 useFrame 中,像上一课那样获取 clock 的 elapsedTime,并把它赋给一个 time 变量:

useFrame((state, delta) =>
{
    const time = state.clock.elapsedTime
    // ...
})

现在把它用在一个带 Math.sin() 的立方体位置上,并给它加上 2,让它保持在当前位置附近:

useFrame((state, delta) =>
{
		const time = state.clock.elapsedTime
    cube.current.position.x = 2 + Math.sin(time)
		// ...
})
lesson demo video preview

阴影似乎移动了一会儿(确切来说是 100 帧),然后就停止了。这是因为我们明确要求 AccumulativeShadows 只渲染 100 帧。

解决方案是让 AccumulativeShadows 持续渲染阴影,我们可以通过把 frames 属性设置为 Infinity 来做到这一点:

<AccumulativeShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
    color="#316d39"
    opacity={ 0.8 }
    frames={ Infinity }
    temporal
>
    {/* ... */}
</AccumulativeShadows>
lesson demo video preview

还不错,但有点跳跃。我们可以分辨出不同的阴影贴图在淡入淡出。原因是,当使用无限的 frames 时,AccumulativeShadows 只混合最后 20 次阴影渲染。我们可以用 blend 属性更改这个值。

把 blend 属性设置为 100:

<AccumulativeShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
    color="#316d39"
    opacity={ 0.8 }
    frames={ Infinity }
    temporal
    blend={ 100 }
>
    {/* ... */}
</AccumulativeShadows>
lesson demo video preview

好多了,但 blend 越高,你在快速移动的物体上看到阴影的可能性就越小。

由于 AccumulativeShadow 高度可参数化,你应该把各种参数添加到像 Leva 这样的调试 UI 中,以找到完美的调整值。

AccumulativeShadow 就到这里。注释掉或删除我们给立方体添加的位置动画,并把灯光辅助工具放回来:

export default function Experience()
{
    // ...
    useHelper(directionalLight, THREE.DirectionalLightHelper, 1)
    
    useFrame((state, delta) =>
    {
        // const time = state.clock.elapsedTime
        // cube.current.position.x = 2 + Math.sin(time)
        cube.current.rotation.y += delta * 0.2
    })
    
    // ...
}

接触阴影

我们要了解的最后一种阴影解决方案叫做 ContactShadows。

由于它不依赖 Three.js 的默认阴影系统,我们将在 <Canvas> 上停用 shadows(你也可以移除该属性):

<Canvas
    shadows={ false }
    camera={ {
        fov: 45,
        near: 0.1,
        far: 200,
        position: [ - 4, 3, 6 ]
    } }
>
    <Experience />
</Canvas>

并注释掉或删除 <AccumulativeShadows>:

{/* <AccumulativeShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
    color="#316d39"
    opacity={ 0.8 }
    frames={ Infinity }
    temporal
    blend={ 100 }
>
    <RandomizedLight
        amount={ 8 }
        radius={ 1 }
        ambient={ 0.5 }
        intensity={ 1 }
        position={ [ 1, 2, 3 ] }
        bias={ 0.001 }
    />
</AccumulativeShadows> */}

关于 ContactShadows,首先要理解的是它在没有灯光参与的情况下工作。很奇怪,对吧?

与 AccumulativeShadows 一样,它作用在一个平面上,这限制了它的用途,但在地板上看起来非常出色。

ContactShadows 会渲染整个场景,有点像方向光的做法,只不过是用相机取代地板的位置,而不是灯光。

然后它会对阴影贴图进行模糊,使它看起来更好。

要实现它,从 @react-three/drei 导入 ContactShadows:

import { ContactShadows, RandomizedLight, AccumulativeShadows, SoftShadows, BakeShadows, useHelper, OrbitControls } from '@react-three/drei'

然后,在 JSX 中任意位置添加 <ContactShadows>:

export default function Experience()
{
    // ...

    return <>
        
        {/* ... */}
        
        <ContactShadows />

        {/* ... */}

    </>
}

默认情况下,它并不适合我们的设置,我们需要应用一些调整。

首先,记住它是一个平面,我们需要把它移到地板正上方,以免出现 z-fighting 问题:

<ContactShadows
    position={ [ 0, - 0.99, 0 ] }
/>

<ContactShadows> 的表面与我们的地板吻合,但我们可以更改它的 scale:

<ContactShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
/>

我们可以用 resolution 提升质量:

<ContactShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
    resolution={ 512 }
/>

我们可以用 far 属性选择阴影渲染上方物体的距离。

我们的场景不太高,但还是选 5 以防万一:

<ContactShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
    resolution={ 512 }
    far={ 5 }
/>

这次,我们要把其余参数添加到 Leva 中,以找到最佳设置。

现在正是你自己练习的好时机。把 Leva 添加到项目中,并创建调整项来控制以下属性:

这是解决方案。

在终端中,用 npm install leva@0.9.34 安装 leva。

在 Experience.jsx 中,从 leva 导入 useControls:

import { useControls } from 'leva'

然后在一个 'contact shadows' 文件夹中创建 color、opacity 和 blur 调整项(别忘了取出这些值):

export default function Experience()
{
    // ...

    const { color, opacity, blur } = useControls('contact shadows', {
        color: '#000000',
        opacity: { value: 0.5, min: 0, max: 1 },
        blur: { value: 1, min: 0, max: 10 },
    })

    // ...
}

现在我们可以把它们用在 <ContactShadows> 上:

<ContactShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
    resolution={ 512 }
    far={ 5 }
    color={ color }
    opacity={ opacity }
    blur={ blur }
/>

并不断调整它们,直到你对结果满意为止。

以下参数效果不错:

const { color, opacity, blur } = useControls('contact shadows', {
    color: '#1d8f75',
    opacity: { value: 0.4, min: 0, max: 1 },
    blur: { value: 2.8, min: 0, max: 10 },
})

正如我们之前提到的,ContactShadow 会渲染从下方看到的场景,并利用这些信息来生成阴影。然后这个阴影会被模糊。对于小型场景,性能应该不错,但对于更复杂的场景,这个过程可能过于繁重,导致帧率下降,尤其是它必须每一帧都执行。

幸运的是,有一种烘焙阴影的方法,即把 <ContactShadow> 上的 frames 属性设置为 1:

<ContactShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
    resolution={ 512 }
    far={ 5 }
    color={ color }
    opacity={ opacity }
    blur={ blur }
    frames={ 1 }
/>

我们选择的数字对应于阴影将在多少帧内生成。由于我们只想生成一次,我们选择了 1。

<ContactShadows> 看起来非常不错,但有局限性:

对于简单的物体展示,它很棒,但对于更复杂或更真实的渲染,你最好使用其他阴影解决方案。

天空

为了让场景更真实,并添加一个漂亮的自然背景,我们可以使用 Sky 类 https://threejs.org/examples/webgl_shaders_sky.html

一如既往,R3F 和 drei 用 Sky 辅助工具让这件事变得非常简单。

从 @react-three/drei 导入 Sky:

import { Sky, ContactShadows, RandomizedLight, AccumulativeShadows, SoftShadows, BakeShadows, useHelper, OrbitControls } from '@react-three/drei'

然后,在 JSX 中任意位置添加 <Sky>:

export default function Experience()
{
    // ...

    return <>
        
        {/* ... */}
        
        <Sky />

        {/* ... */}

    </>
}
lesson demo video preview

我们现在有了一个漂亮的天空。

这个类基于物理,会根据 mieCoefficient、mieDirectionalG、rayleigh 和 turbidity 等各种参数尝试重现真实的天空。我们不打算覆盖这些内容,只调整太阳的位置。

为此,我们要往 Leva 中添加一个调整项。

调用 useControls,把第一个参数设为 'sky',以创建一个同名文件夹,并传入一个带有 sunPosition 属性的对象,将其设置为一个 vector 3 调整项(别忘了取出 sunPosition):

const { sunPosition } = useControls('sky', {
    sunPosition: { value: [ 1, 2, 3 ] }
})

现在我们可以在 <Sky> 中使用这个值:

<Sky sunPosition={ sunPosition } />
lesson demo video preview

注意,这并不是设置太阳位置的常用方式。最好使用球坐标,Three.js 通过 Spherical 类提供了这样的东西。然后你可以创建一个 Vector3,并使用它的 setFromSpherical 方法从球坐标转换为 vector 3(x、y、z)坐标。

使用球坐标会让调整更容易,因为你只需处理两个角度(phi 和 theta),但我们这里不这么做。

最后,为了让场景更真实、更符合逻辑,我们可以把 sunPosition 用在 <directionalLight> 上:

<directionalLight
    ref={ directionalLight }
    position={ sunPosition }
    intensity={ 4.5 }
    castShadow
    shadow-mapSize={ [ 1024, 1024 ] }
    shadow-camera-near={ 1 }
    shadow-camera-far={ 10 }
    shadow-camera-top={ 5 }
    shadow-camera-right={ 5 }
    shadow-camera-bottom={ - 5 }
    shadow-camera-left={ - 5 }
/>
lesson demo video preview

环境贴图

在之前的课程中,我们实现了由 6 张纹理(立方体的每个面一张)构成的环境贴图。

但还有其他实现环境贴图的方式。

我们可以使用 HDRI 纹理,它就像一张 360° 照片,其中的像素数据超出了传统的颜色范围。这些可以用特殊相机拍摄,也可以用 3D 生成。

实现这样的环境贴图会稍微复杂一些,但一如既往,drei 通过 Environment 辅助工具让这个过程变得更容易。

使用立方体贴图的设置

在接下来的部分,我们将使用环境贴图来照亮场景。为了避免冲突,我们要移除当前的灯光以及 <Sky>。

在 Experience.jsx 中,注释掉或删除 <directionalLight>、<ambientLight> 和 <Sky>:

{/* <directionalLight
    ref={ directionalLight }
    position={ sunPosition }
    intensity={ 4.5 }
    castShadow
    shadow-mapSize={ [ 1024, 1024 ] }
    shadow-camera-near={ 1 }
    shadow-camera-far={ 10 }
    shadow-camera-top={ 5 }
    shadow-camera-right={ 5 }
    shadow-camera-bottom={ - 5 }
    shadow-camera-left={ - 5 }
/> */}
{/* <ambientLight intensity={ 1.5 } /> */}
{/* <Sky sunPosition={ sunPosition } /> */}

从 @react-three/drei 导入 Environment:

import { Environment, Sky, ContactShadows, RandomizedLight, AccumulativeShadows, SoftShadows, BakeShadows, useHelper, OrbitControls } from '@react-three/drei'

首先,我们将使用你可以在 /public/environmentMaps/ 文件夹中找到的传统立方体贴图。

把 <Environment> 添加到 JSX 中,并设置它的 files 属性,使其包含一个纹理数组:

export default function Experience()
{
    // ...

    return <>
        
        <Environment
            files={ [
                './environmentMaps/2/px.jpg',
                './environmentMaps/2/nx.jpg',
                './environmentMaps/2/py.jpg',
                './environmentMaps/2/ny.jpg',
                './environmentMaps/2/pz.jpg',
                './environmentMaps/2/nz.jpg',
            ] }
        />

        {/* ... */}
    </>
}

你应该能看到环境贴图照亮了场景。

强度

默认的 envMapIntensity 被设置为 1,这并不多。

让我们创建一个调整项来控制它。

调用 useControls,把第一个参数设为 'environment map',以得到一个同名文件夹,并传入一个带有 envMapIntensity 属性的对象,取值范围在 0 到 12 之间(别忘了取出 envMapIntensity):

const { envMapIntensity } = useControls('environment map', {
    envMapIntensity: { value: 1, min: 0, max: 12 }
})

我们可以使用 Scene 上提供的 environmentIntensity 属性来更改场景中所有对象的环境贴图强度。

要访问 R3F 使用的当前 Scene,我们可以使用 useThree 辅助工具。

从 @react-three/fiber 导入 useThree:

import { useThree, useFrame } from '@react-three/fiber'

紧跟在 envMapIntensity 的 useControls 之后,调用 useThree(),把它保存到 scene 变量中,并提供以下函数作为参数:

const { envMapIntensity } = useControls('environment map', {
    // ...
})

const scene = useThree(state => state.scene)

这是一个选择器。state 包含的内容远不止 Scene,但我们只选择 scene 属性。

接下来,我们想在 scene 上更新 environmentIntensity。我们可以立刻做,但那样就意味着只要组件重新渲染就会更新这个属性,无论是什么触发了那次重新渲染。

正确的做法是在 envMapIntensity(来自 useControls 的那个)发生变化时去做。我们可以用 useEffect 并将 [ envMapIntensity ] 作为依赖来实现:

const scene = useThree(state => state.scene)
useEffect(() =>
{
    scene.environmentIntensity = envMapIntensity
}, [ envMapIntensity ])
lesson demo video preview

选择一个适合你的值(在我的例子中是 3.5):

const { envMapIntensity } = useControls('environment map', {
    envMapIntensity: { value: 3.5, min: 0, max: 12 }
})

背景

如果你想在背景中看到环境贴图,只需添加一个 background 属性:

<Environment
    background
    files={ [
        './environmentMaps/2/px.jpg',
        './environmentMaps/2/nx.jpg',
        './environmentMaps/2/py.jpg',
        './environmentMaps/2/ny.jpg',
        './environmentMaps/2/pz.jpg',
        './environmentMaps/2/nz.jpg',
    ] }
/>

HDRI 纹理

如前所述,我们可以不用 6 张图片,而是用一张覆盖周围环境的图片。

它就像一张 360 照片,通常采用高动态范围(High Dynamic Range),以使光照数据更准确。这是合理的,因为光实际上并不会在某个范围就停止。如果你看向太阳,它会比看向灯泡亮得多(不要直视太阳)。

在 /public/environmentMaps/ 文件夹中已经有一张名为 the_sky_is_on_fire_2k.hdr 的 HDR 图片。

把我们传给 files 属性的纹理数组替换成单个字符串,即 hdr 文件的路径:

<Environment
    background
    files="./environmentMaps/the_sky_is_on_fire_2k.hdr"
/>

如你所见,Environment 辅助工具已经处理好了。

随意调整 envMapIntensity 以获得更好的结果。

顺便提一下,如果你在寻找 HDRI 内容,最好的地方之一是 Poly Haven(以前叫 HDRI Haven)。

请确保下载 HDR 版本而不是 EXR。同时尽量保持分辨率尽可能小,因为环境贴图会占用大量资源。

预设

比必须下载那些 HDRI 更好的是,drei 创建的预设会直接从 Poly Haven 获取文件。

把 files 替换成 preset,并从以下列表中选择一个 https://github.com/pmndrs/drei/blob/master/src/helpers/environment-assets.ts

<Environment
    background
    preset="sunset"
/>

自定义环境

但如果我们想稍微调整一下呢?

假设我们想在一侧放置某种巨大的红色矩形,以确保从这个侧面有红光照射我们的物体。

好消息是,这非常简单。

我们所要做的就是把一个 <mesh>(或任何我们想成为环境贴图一部分的东西)放在 <Environment> 里面。

一个好做法是先把 <mesh> 创建在 <Environment> 外面,以确保它位置正确:

<Environment
    background
    preset="sunset"
/>

<mesh position-z={ - 5 } scale={ 10 }>
    <planeGeometry />
    <meshBasicMaterial color="red" />
</mesh>

然后把它放到 <Environment> 里面:

<Environment
    background
    preset="sunset"
>
    <mesh position-z={ - 5 } scale={ 10 }>
        <planeGeometry />
        <meshBasicMaterial color="red" />
    </mesh>
</Environment>
lesson demo video preview

红色矩形会被添加到初始环境贴图之上,我们可以从物体上看到它的影响。

我们也可以创建一个不依赖初始环境贴图的场景。

为此,移除 preset 属性:

<Environment
    background
>
    <mesh position-z={ - 5 } scale={ 10 }>
        <planeGeometry />
        <meshBasicMaterial color="red" />
    </mesh>
</Environment>

注意,默认情况下,环境贴图的背景是黑色的,这就是为什么只有红色那一侧被照亮。

我们可以通过在 <Environment> 中渲染的场景上设置背景颜色来改变这种状况,而我们已经学过怎么做了。

在 <Environment> 内添加一个 <color>,把它的值设为 'blue',并把它的 attach 属性设为 "background":

<Environment
    background
>
    <color args={ [ 'blue' ] } attach="background" />
    {/* ... */}
</Environment>

背景颜色正在影响整个场景的光照。

让我们把它改成黑色:

<Environment
    background
>
    <color args={ [ '#000000' ] } attach="background" />
    {/* ... */}
</Environment>

使用 mesh 照亮场景听起来像是一个受限的解决方案,因为光不应该局限于颜色光谱。在现实生活中,我们应该能够让红色平面越来越亮。在我们眼中,它应该像加热到极高温的金属那样变成白色。

好消息是,我们能做到。

首先,在 <meshBasicMaterial> 上使用像 red 这样的字面值并不能让我们控制颜色的实际强度。但由于 color 属性被用来实例化一个 Three.js Color,我们可以从文档中看到,我们也可以传入一个包含分开的红、绿、蓝值的数组。

把 <meshBasicMaterial> 的 "red" 颜色替换为 [ 1, 0, 0 ]:

<Environment
    background
>
    <color args={ [ '#000000' ] } attach="background" />
    <mesh position-z={ - 5 } scale={ 10 }>
        <planeGeometry />
        <meshBasicMaterial color={ [ 1, 0, 0 ] } />
    </mesh>
</Environment>

我们得到了完全相同的颜色,但现在我们让它超过 1:

<Environment
    background
>
    <color args={ [ '#000000' ] } attach="background" />
    <mesh position-z={ - 5 } scale={ 10 }>
        <planeGeometry />
        <meshBasicMaterial color={ [ 2, 0, 0 ] } />
    </mesh>
</Environment>

如你所见,红色平面开始变得更亮,场景也被照得更亮。

让我们疯狂一点:

<Environment
    background
>
    <color args={ [ '#000000' ] } attach="background" />
    <mesh position-z={ - 5 } scale={ 10 }>
        <planeGeometry />
        <meshBasicMaterial color={ [ 10, 0, 0 ] } />
    </mesh>
</Environment>

虽然以这种方式给环境贴图添加灯光是一个可行的解决方案,但有一个专门为此而生的辅助工具,名为 Lightformer。

注释掉或删除 <Environment> 内的 <mesh>:

<Environment
    background
>
    <color args={ [ '#000000' ] } attach="background" />
    {/* <mesh position-z={ - 5 } scale={ 10 }>
        <planeGeometry />
        <meshBasicMaterial color={ [ 10, 0, 0 ] } toneMapped={ false } />
    </mesh> */}
</Environment>

全都是黑的,因为我们不再有任何光源。

从 @react-three/drei 导入 Lightformer:

import { Lightformer, Environment, Sky, ContactShadows, RandomizedLight, AccumulativeShadows, SoftShadows, BakeShadows, useHelper, OrbitControls } from '@react-three/drei'

把它添加到 <Environment> 中,并使用与我们 <mesh> 相同的 position 和 scale:

<Environment
    background
>
    <color args={ [ '#000000' ] } attach="background" />
    <Lightformer position-z={ - 5 } scale={ 10 } />
</Environment>

我们可以用 color 属性选择颜色,但不是像我们为 <meshBasicMaterial> 做的那样传入一个值超过 1 的数组,我们可以传入一个字面值 color,然后用 intensity 属性选择强度:

<Lightformer
    position-z={ - 5 }
    scale={ 10 }
    color="red"
    intensity={ 10 }
/>

而 Lightformer 很酷的一点是,我们还可以用 form 属性使用各种形状,比如 ring 或 circle(如果你想看得更清楚,把 scale 减小到 5):

<Lightformer
    position-z={ - 5 }
    scale={ 5 }
    color="red"
    intensity={ 10 }
    form="ring"
/>

虽然这个 ring 示例没有太大区别,但在带有反射的真实场景中使用它会为构图和真实感增色不少,正如你在这个示例中看到的:https://codesandbox.io/s/lwo219?file=/src/App.js:917-1016

让我们把预设放回来:

<Environment
    background
    preset="sunset"
>
    <color args={ [ '#000000' ] } attach="background" />
    <Lightformer
        position-z={ - 5 }
        scale={ 5 }
        color="red"
        intensity={ 10 }
        form="ring"
    />
</Environment>

如你所见,Lightformer 现在成为了环境贴图的一部分。为此,整个环境贴图必须重新渲染。虽然这会消耗一些资源,但默认情况下它只在第一帧执行一次。

既然环境贴图已经被渲染,我们可以选择它的 resolution:

<Environment
    background
    preset="sunset"
    resolution={ 32 }
>
    <color args={ [ '#000000' ] } attach="background" />
    <Lightformer
        position-z={ - 5 }
        scale={ 5 }
        color="red"
        intensity={ 10 }
        form="ring"
    />
</Environment>

分辨率越小,对帧率越有利。

低分辨率的环境贴图看起来会模糊,但这并不总是坏事。如果它只是用来照亮你的物体,你不会注意到它,可以使用非常小的分辨率。

为了接下来的内容,让我们注释掉 <Environment> 中所有已有的内容。

<Environment
    background
    preset="sunset"
    resolution={ 32 }
>
    {/* <color args={ [ '#000000' ] } attach="background" /> */}
    {/* <Lightformer
        position-z={ - 5 }
        scale={ 5 }
        color="red"
        intensity={ 10 }
        form="ring"
    /> */}
    {/* <mesh position-z={ - 5 } scale={ 10 }>
        <planeGeometry />
        <meshBasicMaterial color={ [ 10, 0, 0 ] } toneMapped={ false } />
    </mesh> */}
</Environment>

注意,resolution 属性不会影响环境贴图,因为 <Environment> 内没有任何东西需要渲染。

让我们也移除或注释掉 resolution。

地面

当使用环境贴图作为背景时,我们会有一种物体漂浮着的感觉,因为图像在无限远处。

通过添加一个 ground 属性,环境贴图的投影会让物体下方的地板看起来很近。

把 ground 属性添加到 <environment>,用以下参数代替 background 属性:

<Environment
    preset="sunset"
    ground={ {
        height: 7,
        radius: 28,
        scale: 100
    } }
>
    {/* ... */}
</Environment>
lesson demo video preview

这个地面被认为位于场景的 0 高度。这意味着,理论上我们的物体在地面内部。我们可以用它们的 position-y 属性把它们稍微上移来修正这一点:

<mesh castShadow position-y={ 1 } position-x={ - 2 }>
    {/* ... */}
</mesh>

<mesh castShadow ref={ cube } position-y={ 1 } position-x={ 2 } scale={ 1.5 }>
    {/* ... */}
</mesh>

<mesh receiveShadow position-y={ 0 } rotation-x={ - Math.PI * 0.5 } scale={ 10 }>
    {/* ... */}
</mesh>
lesson demo video preview

我们其实可以移除或注释掉绿色地板平面,并用它的 position 属性把 <ContactShadows> 向上移动:

<ContactShadows
    position={ [ 0, 0, 0 ] }
    scale={ 10 }
    resolution={ 512 }
    far={ 5 }
    color={ color }
    opacity={ opacity }
    blur={ blur }
/>
lesson demo video preview

ground 的设置有点令人困惑,最好的解决方案是添加一些控制项来找到最合适的值。

把以下参数添加到 environment map 已有的 useControls 中:

const { envMapIntensity, envMapHeight, envMapRadius, envMapScale } = useControls('environment map', {
    envMapIntensity: { value: 7, min: 0, max: 12 },
    envMapHeight: { value: 7, min: 0, max: 100 },
    envMapRadius: { value: 28, min: 10, max: 1000 },
    envMapScale: { value: 100, min: 10, max: 1000 }
})

并把它们用在 <Environment> 的 ground 上:

<Environment
    preset="sunset"
    ground={ {
        height: envMapHeight,
        radius: envMapRadius,
        scale: envMapScale
    } }
>
</Environment>
lesson demo video preview

Stage

这一课我们做了大量配置。虽然并不太复杂,但有时候我们只想要一个默认好看、配置极少的设置。

而这正是 Stage 辅助工具所做的。

Stage 会为我们设置环境贴图、阴影和两盏方向光。它还会让场景居中。如果你想要一个快速简单的设置,它是一个不错的方案。

为了简单起见,并且不丢失我们之前的设置,我们要注释掉 JSX 中除 <OrbitControls> 和 <Perf> 之外的所有内容(甚至 <mesh>)。

然后我们要复制球体 <mesh> 和立方体 <mesh>:

<Perf position="top-left" />

<OrbitControls makeDefault />

{/* <BakeShadows /> */}
{/* <SoftShadows size={ 25 } samples={ 10 } focus={ 0 } /> */}

{/* <Environment
    background
    // files={ [
    //     './environmentMaps/2/px.jpg',
    //     './environmentMaps/2/nx.jpg',
    //     './environmentMaps/2/py.jpg',
    //     './environmentMaps/2/ny.jpg',
    //     './environmentMaps/2/pz.jpg',
    //     './environmentMaps/2/nz.jpg',
    // ] }
    // files="./environmentMaps/the_sky_is_on_fire_2k.hdr"
    preset="sunset"
    resolution={ 32 }
    ground={ {
        height: envMapHeight,
        radius: envMapRadius,
        scale: envMapScale
    } }
>
</Environment> */}

{/* <AccumulativeShadows
    position={ [ 0, - 0.99, 0 ] }
    scale={ 10 }
    color="#316d39"
    opacity={ 0.8 }
    frames={ Infinity }
    temporal
    blend={ 100 }
>
    <RandomizedLight
        amount={ 8 }
        radius={ 1 }
        ambient={ 0.5 }
        intensity={ 3 }
        position={ [ 1, 2, 3 ] }
        bias={ 0.001 }
    />
</AccumulativeShadows> */}

{/* <Sky sunPosition={ sunPosition } /> */}

{/* <directionalLight
    ref={ directionalLight }
    position={ sunPosition }
    intensity={ 4.5 }
    castShadow
    shadow-mapSize={ [ 1024, 1024 ] }
    shadow-camera-near={ 1 }
    shadow-camera-far={ 10 }
    shadow-camera-top={ 5 }
    shadow-camera-right={ 5 }
    shadow-camera-bottom={ - 5 }
    shadow-camera-left={ - 5 }
/> */}
{/* <ambientLight intensity={ 1.5 } /> */}

{/* <mesh castShadow position-y={ 1 } position-x={ - 2 }>
    <sphereGeometry />
    <meshStandardMaterial color="orange" envMapIntensity={ envMapIntensity } />
</mesh> */}

{/* <mesh castShadow position-y={ 1 } ref={ cube } position-x={ 2 } scale={ 1.5 }>
    <boxGeometry />
    <meshStandardMaterial color="mediumpurple" envMapIntensity={ envMapIntensity } />
</mesh> */}

{/* <mesh receiveShadow position-y={ 0 } rotation-x={ - Math.PI * 0.5 } scale={ 10 }>
    <planeGeometry />
    <meshStandardMaterial color="greenyellow" envMapIntensity={ envMapIntensity } />
</mesh> */}

{/* <ContactShadows
    position={ [ 0, 0, 0 ] }
    scale={ 10 }
    resolution={ 512 }
    far={ 5 }
    color={ color }
    opacity={ opacity }
    blur={ blur }
/> */}

<mesh castShadow position-y={ 1 } position-x={ - 2 }>
    <sphereGeometry />
    <meshStandardMaterial color="orange" envMapIntensity={ envMapIntensity } />
</mesh>

<mesh castShadow ref={ cube } position-y={ 1 } position-x={ 2 } scale={ 1.5 }>
    <boxGeometry />
    <meshStandardMaterial color="mediumpurple" envMapIntensity={ envMapIntensity } />
</mesh>

它们应该看起来完全是黑色的,因为当前没有灯光或环境贴图。

从 @react-three/drei 导入 Stage:

import { Stage, Lightformer, Environment, Sky, ContactShadows, RandomizedLight, AccumulativeShadows, SoftShadows, BakeShadows, useHelper, OrbitControls } from '@react-three/drei'

现在用 <Stage> 包裹这两个 <mesh>:

<Stage>
    <mesh position-y={ 1 } position-x={ - 2 }>
        <sphereGeometry />
        <meshStandardMaterial color="orange" />
    </mesh>

    <mesh ref={ cube } position-y={ 1 } position-x={ 2 } scale={ 1.5 }>
        <boxGeometry />
        <meshStandardMaterial color="mediumpurple" />
    </mesh>
</Stage>

很方便,对吧?

我们仍然可以通过向 shadow 属性传入一个对象来控制阴影,并且可以通过我们传给 shadow 的对象上的 type 属性在累积阴影和接触阴影之间选择。

让我们使用接触阴影:

<Stage
    shadows={ { type: 'contact' } }
>

我们得到相同的结果,因为接触阴影就是默认的那一个。但现在我们可以向这个对象传入更多属性来调整阴影:

<Stage
    shadows={ { type: 'contact', opacity: 0.2, blur: 3 } }
>

我们还可以选择不同的环境贴图预设:

<Stage
    shadows={ { type: 'contact', opacity: 0.2, blur: 3 } }
    environment="sunset"
>

更改方向光的 preset('rembrandt'、'portrait'、'upfront'、'soft'):

<Stage
    shadows={ { type: 'contact', opacity: 0.2, blur: 3 } }
    environment="sunset"
    preset="portrait"
>

最后,我们可以更改光照的强度。由于 Stage 在内部会像我们之前那样更新 scene.envMapIntensity,所以让我们移除或注释掉我们自己用 useThree 和 useEffect 更新它的那部分:

// const scene = useThree(state => state.scene)
// useEffect(() =>
// {
//     scene.environmentIntensity = envMapIntensity
// }, [ envMapIntensity ])

并把 envMapIntensity 传给 <Stage> 的 intensity prop:

<Stage
    shadows={ { type: 'contact', opacity: 0.2, blur: 3 } }
    environment="sunset"
    preset="portrait"
    intensity={ envMapIntensity }
>

这并不完全相同,因为 intensity 也会更新 Stage 中的方向光,但它很方便,所以我们就保持这样。

你还可以调整更多内容,但我们就在这里停下。

更进一步

我们已经涵盖了改善场景环境最著名、最实用的解决方案,但还有更多内容等待你去探索。

去查看 drei 中的 Staging 部分以获取灵感 https://github.com/pmndrs/drei#staging